Skip to content

fix(drivers): undefined 比较数一律拒收 —— 闸落在任何发射器/守卫之前,两个毛病同闸消灭 (#6050) - #6142

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-6050-undefined-comparand-refusal
Aug 7, 2026
Merged

fix(drivers): undefined 比较数一律拒收 —— 闸落在任何发射器/守卫之前,两个毛病同闸消灭 (#6050)#6142
os-zhuang merged 1 commit into
mainfrom
claude/issue-6050-undefined-comparand-refusal

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #6050

按 issue #6050 的裁决落档(2026-08-07,B 案:undefined 比较数一律响亮拒收,INVALID_FILTER / 400,#5347-A 同族信封)实施。

前提复核(先做的第一件事)

五格矩阵在最新 origin/main(cba7454df,立单时记的是 efedd289f)逐格复现,而且多量了四格。同一个 TursoDriver、同一条 filter、同一份 fixture(d 在 1-2 有值、3-4 为 NULL),答案取决于它是用哪个 url 构造的:

filter LOCAL(继承 SqlDriver) REMOTE(RemoteTransport)
{ d: undefined } 抛裸 knex Undefined binding(s)(code/status 均 undefined) ['3','4']
{ d: { $eq: undefined } } 同上 ['3','4']
{ $not: { d: undefined } } 同上(compiling WHERE) ['1','2']
{ d: { $ne: undefined } } ['1','2'] ['1','2']
{ $not: { d: { $ne: undefined } } } [] ['3','4']
{ d: { $in: [undefined] } } 抛裸 knex []
{ d: { $nin: [undefined] } } 抛裸 knex ['3','4']
{ d: { $gt: undefined } } 抛裸 knex []
{ $or: [{ d: undefined }, { a: 'x' }] } 抛裸 knex 全表

对照组 {d: null} / {$eq: null} / {$ne: null} 两面一致且正确 —— 毛病只在 undefined 这一个值上。

两个毛病,一道闸

A —— 抛出的那几格没有 ADR-0112 信封。 knex 的 Undefined binding(s) detected when compiling SELECT 既无 code 也无 status,mapDataError 落默认分支,一条「调用方 filter 写坏了」的错误以不透明 500 到达客户端。#1116 / #4436 为这条通路清点过同类形态,唯独漏了这格。

B —— 守卫与它自己的发射器分裂。 $ne 发射器读 coerced == null(宽松 ⇒ undefined 编译成 IS NOT NULL,一条 TOTAL 谓词),而必须钉住这个发射器的两张极性表读 === null(严格 ⇒ 判它「不 total」且「NULL 行满足它」)。nullGuardForFieldSpec 于是把一条已 total 的谓词包成 d IS NULL OR d IS NOT NULL —— 恒真 —— 取反后恒假,答 []。这正是 #5298 立的不变量在它自己的定义处被破坏:每张极性表钉的是它自己发射器的拼写。

闸落在比较数进入任何发射器或守卫之前,两个毛病同闸消灭 —— knex 再也见不到 undefined 绑定,守卫与发射器对 undefined 的分歧变成不可达,而不是「被修好」。

拒收位置逐个清点:直接比较数、单值算子比较数($eq/$ne/$gt/$gte/$lt/$lte 与 LIKE 族)、列表算子数组的成员($in/$nin/$between),以及嵌在 $and/$or/$not 里的以上各位。$null / $existsundefined 保持它们自己的布尔域拒收措辞(那条消息更贴切 —— #5240「一个条件一种措辞」两个方向都适用)。两驱动的拒收句子逐字一致。

null 一字未动

{ f: null }{ $eq: null }IS NULL;{ $ne: null }IS NOT NULL;$null: true/false 不变;null 仍是合法 $in 成员;$not 下同。三个测试文件各带一段 null 对照块钉这件事。

调用方清点(issue 的前置要求)

  • 静态:grep 仓内 where/filter 字面量里的可选链与动态装配点 —— 生产面用的都是 if (x) where.k = x?? null,即裁决建议的作者侧修法本身,零个依赖旧行为。
  • 实测:全仓 build(71 tasks 全过)+ 全仓 test(135 个任务 134 成功)。唯一失败是 @objectstack/dogfoodsemantic-roles.dogfood.test.ts 在满载下 beforeAll 10s 超时,单独重跑 5/5 通过 —— 与本改动无关的负载抖动。两个 example app 均 build 通过,dogfood 会真正把 showcase stack 起起来跑。⇒ 没有依赖 remote 现行 IS NULL 行为的生产调用方,不触发「停手」条件。

测试

反向验证(方向预判,实测)

预判:不是一个方向。拒收用例该红,对照用例该绿,而且 driver-sql 侧的红该由两种不同机制产生 —— 因为未修的驱动本来就用两种方式回答这一族。

实测(只删两处 assertDefinedComparands 调用,其余不动):

  • driver-sql:22 failed / 6 passed(共 28)。留绿的 6 个正好是全部对照块。红的机制是混合的:多数位置红在抛出 knex 的裸 Undefined binding(s) —— 一个没有 code/status 的 Error,所以 refusalOf 正常返回、只有信封断言失败。⚠️ 一个只断言「它抛了」的测试会在本 issue 所针对的那个驱动上保持绿色 —— 这就是每一例都断言 codestatus 的原因。其余($ne、LIKE 族、{ $not: { … $ne: undefined } })红在答了:从未抛过,且 {$not:{$ne:undefined}}[],即毛病 B 的恒真式。
  • driver-turso:20 failed / 9 passed(共 29),20 个全部红在 refusalOf 的「本该拒收,却编译成了 …」分支 —— 即全部靠「答出来」,与预判一致(这个 transport 从不抛)。留绿的 9 个是对照:null 块、节点位拒收、where 缺席、布尔域用例、常规词表。

门禁

pnpm --filter @objectstack/driver-sql --filter @objectstack/driver-turso typecheck   → Done / Done
pnpm --filter @objectstack/driver-sql   test  → Test Files 65 passed | 4 skipped   Tests 906 passed | 46 skipped
pnpm --filter @objectstack/driver-turso test  → Test Files 24 passed              Tests 788 passed
node scripts/check-nul-bytes.mjs → OK (5860 files, no raw ASCII control bytes)

实测但改的其余无值语义面 → #6125

issue「落点」节要求裁决后逐格实测其余面。做完了,记录如下 —— 同一个 { d: undefined },仓内五种读法:

求值面 { d: undefined } 读法
driver-sql / turso LOCAL 抛裸 knex
turso REMOTE ['3','4'] undefined ≡ null
driver-memory(mingo) ['3','4'] undefined ≡ null
formula matchesFilterCondition 只匹配键缺失的行 undefined ≢ null
service-analytics read-scope-sql "t"."d" = ? bound null = NULL → 匹配零行

三条都是语义争议而非实现细节,派工单明确要求「有语义争议只记录留后续单、不扩大 diff 赌时限」:formula 的读法会顺手替 #5299 拍板;read-scope-sql 只会抛 READ_SCOPE_COMPILE_FAILED / 500(与本裁决的 400 不是一个信封,且 read scope 是平台自己编译的、不是调用方输入);driver-memory / mongodb 是 #5499 的冻结面,按裁决零行为改动。已如实立单 #6125(finding,未指派,带 Blocked-by: #6050),其中也写明了本 PR 落地后 driver-memory 会在这一格上与 driver-sql 不一致 —— 那是裁决接受的代价,不是回归。

changeset

.changeset/undefined-comparand-refusal.md(driver-sql + driver-turso 双 patch),升级说明写明「undefined 比较数从静默/崩溃变为 400 拒收,作者侧修法是显式判空或用 null / $null」,并附两种正确写法。


🤖 Generated with Claude Code

https://claude.ai/code/session_01WyvqvKMG6asi9aXjKE6xtx


Generated by Claude Code

… or guard reads it (#6050)

`undefined` in a comparand position had two answers in one driver, chosen by
the url a TursoDriver was constructed with. Measured on origin/main
(cba7454), four rows with `d` valued on 1-2 and NULL on 3-4:

  { d: undefined }                    LOCAL: knex "Undefined binding(s)"  REMOTE: ['3','4']
  { d: { $eq: undefined } }           LOCAL: knex "Undefined binding(s)"  REMOTE: ['3','4']
  { $not: { d: undefined } }          LOCAL: knex "Undefined binding(s)"  REMOTE: ['1','2']
  { d: { $ne: undefined } }           LOCAL: ['1','2']                    REMOTE: ['1','2']
  { $not: { d: { $ne: undefined } } } LOCAL: []                           REMOTE: ['3','4']
  { d: { $in: [undefined] } }         LOCAL: knex "Undefined binding(s)"  REMOTE: []
  { d: { $gt: undefined } }           LOCAL: knex "Undefined binding(s)"  REMOTE: []

Two defects. (A) The throwing rows carried no ADR-0112 envelope: knex's error
has neither `code` nor `status`, so mapDataError served an opaque 500 for a
caller mistake in a filter (#1116/#4436 catalogued the shape; this one was
missing from the list). (B) driver-sql's `$ne` emitter read `coerced == null`
while its own polarity tables (operatorIsNullTotal, nullValueSatisfiesOperator)
read `=== null`, so nullGuardForFieldSpec wrapped an already-TOTAL predicate in
`d IS NULL OR d IS NOT NULL` and its negation answered []. That is #5298's
invariant — a polarity table pins the spelling of its own emitter — broken at
its own definition.

Ruled REFUSED on 2026-08-07 (#6050 ruling B, the #5347-A disposition):
FieldOperatorsSchema declares no undefined comparand, `{ f: undefined }` is
indistinguishable from an absent key while meaning the opposite, and undefined
cannot survive JSON so it is always an in-process authoring bug — the
`{ owner_id: ctx.user?.id }` that silently matched every env-wide row.

One gate, placed before any emitter or guard, kills both: knex never sees an
undefined binding, and guard-vs-emitter disagreement about undefined becomes
unreachable rather than repaired.

- driver-sql: on the reduceFilterKey validation walk, beside the $null/$exists
  refusals, plus applyFilters' plain-map branch (which the walk cannot see —
  `{ d: undefined }` cannot make hasMongoOperators true). One function, two
  call sites.
- driver-turso: a whole-subtree pre-walk at buildWhereSQL entry, which is what
  makes `{ $not: { d: undefined } }` hold — compiling key by key would reach
  nullSafeNegationOperand with the undefined still in it.
- Both sides' `== null` / `|| === undefined` spellings tightened to `=== null`
  for #5347's reason: a lenient test silently resumes answering if the gate
  ever moves.

Positions enumerated, not swept: direct comparand, single-value operators, and
members of $in/$nin/$between arrays, at every depth. $null/$exists keep their
own boolean-domain refusal (#5240: one condition, one wording, both ways). The
two drivers' refusal sentences are verbatim identical.

null is untouched in every position: { f: null }, $eq/$ne: null, $null, and
null as an $in member all compile exactly as before, pinned by control blocks
in all three test files.

Callers measured, per the issue's precondition: full repo build (71 tasks) and
full repo test (134/135) pass, both example apps build, and the dogfood suite
boots the showcase stack — no production caller depends on the old behaviour.

Other no-value surfaces measured and NOT changed: formula reads undefined as
key-absence, read-scope-sql compiles `= NULL`, driver-memory reads it as null.
Three further readings, each a separate semantic ruling with its own envelope —
filed as #6125 rather than widened into this diff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WyvqvKMG6asi9aXjKE6xtx
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 3:23am

Request Review

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-sql, @objectstack/driver-turso.

12 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via @objectstack/driver-sql, @objectstack/driver-turso)
  • content/docs/deployment/cli.mdx (via @objectstack/driver-turso)
  • content/docs/deployment/environment-variables.mdx (via @objectstack/driver-turso)
  • content/docs/deployment/self-hosting.mdx (via @objectstack/driver-turso)
  • content/docs/getting-started/glossary.mdx (via @objectstack/driver-sql, @objectstack/driver-turso)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/driver-sql)
  • content/docs/plugins/anatomy.mdx (via @objectstack/driver-sql)
  • content/docs/plugins/packages.mdx (via @objectstack/driver-sql, @objectstack/driver-turso)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/driver-sql)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/driver-sql)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/driver-sql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/driver-sql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 7, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 7, 2026 03:44
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit acf34e3 Aug 7, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-6050-undefined-comparand-refusal branch August 7, 2026 04:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants